纯 ESM 包:现代 JavaScript 模块化指南
- 原文链接: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
- 机器翻译: Gemini 2.5 Pro Preview
- 提示词: 翻译以下文档,并且结合你的前端开发专业知识补充相关的知识点说明,对文章内容进行结构化,以 Markdown 格式返回,文字风格是技术博客。
- 翻译理由:越来越多的库开发者讲自己的包直接打包成纯 ESM 形式,并且指向这个文档,让库使用者也尽快迁移到 ESM。
核心问题:无法再使用 require()
关键点:一个 Pure ESM 包不能再通过 CommonJS 的 require()
函数来同步导入。
// ❌ 这种方式在 CommonJS 项目中会报错
// const foo = require('pure-esm-package');
// ✅ 需要改用 ESM 的 import 语法
// import foo from 'pure-esm-package';
这种转变是 JavaScript 生态系统逐步拥抱官方标准模块系统的一部分。虽然带来了阵痛,但长远来看,它统一了前后端的模块规范,带来了诸多好处。
如何应对 Pure ESM 包?
面对这种情况,你有以下几种选择:
-
迁移你的项目到 ESM (强烈推荐):
- 方法:在你的代码中使用
import foo from 'pure-esm-package'
替代const foo = require('pure-esm-package')
。 - 配套措施:需要在
package.json
中添加"type": "module"
声明,并可能需要调整构建配置、文件扩展名等。下文会详细介绍。 - 前端视角:现代前端框架(如 Vue 3, React with Vite, SvelteKit, Next.js 12+)原生或更好地支持 ESM。迁移到 ESM 可以更好地利用 Tree Shaking(摇树优化)减少最终打包体积,并与浏览器原生模块加载机制保持一致。
- 方法:在你的代码中使用
-
在 CommonJS 中使用动态
import()
(异步上下文):- 方法:如果你的代码允许异步操作,可以使用
await import('pure-esm-package')
来加载 ESM 包。 - 示例:
// 在 async 函数或顶层 await (Node.js >= 14.8) 中
async function loadMyModule() {
const { default: foo } = await import("pure-esm-package");
// 注意:ESM 默认导出的模块需要通过 .default 访问
// 如果是命名导出,则: const { namedExport } = await import('pure-esm-package');
foo.doSomething();
} - 限制:这只能在异步函数或支持顶层
await
的环境中使用。无法用于需要同步返回模块的场景。 - 前端视角:动态
import()
在前端常用于代码分割(Code Splitting),按需加载路由或组件,以优化首屏加载性能。这里的用法类似,但目的是解决 CJS/ESM 互操作性问题。
- 方法:如果你的代码允许异步操作,可以使用
-
锁定旧版本:
- 方法:暂时停留在该 Pure ESM 包的最后一个 CommonJS 兼容版本。
- 风险:你将无法获得该包后续的新功能、性能优化和安全修复。这通常只是一个短期缓兵之计。
-
尝试 Node.js 22+ 的
require()
ESM 支持 (不推荐):- 背景:Node.js 22 开始实验性地支持通过
require()
加载 同步 ESM 模块图。 - 强烈建议:官方和社区普遍不推荐依赖此特性。它可能存在性能问题、兼容性陷阱,且违背了 ESM 的设计哲学。迁移到 ESM 才是正途。
- 背景:Node.js 22 开始实验性地支持通过
最低环境要求:请确保你的 Node.js 版本至少为 v16,强烈推荐使用 Node.js 18 或更高版本,因为许多现代工具和库都已将此作为最低要求,并且对 ESM 的支持更为完善。
核心理念:ESM 可以导入 CommonJS 包(通常没有问题),但 CommonJS 包无法同步 require()
ESM 包。这是单向的兼容性。
请注意:作者明确表示,其仓库不是解答通用 ESM、TypeScript、Webpack、Jest、ts-node、Create React App (CRA) 等工具支持问题的场所。请查阅相应工具的官方文档或社区。
FAQ:迁移与实践详解
Q1: 如何将我的 CommonJS 项目迁移到 ESM?
以下是关键步骤:
-
package.json
设置:- 添加
"type": "module"
。这告诉 Node.js (和许多构建工具) 默认将.js
文件视为 ESM。 - 将
"main": "index.js"
替换为"exports": "./index.js"
。"exports"
字段提供了更精细的包入口点控制,是现代 Node.js 包的标准。 - 更新
"engines"
字段,建议至少"node": ">=18"
。
- 添加
-
代码调整:
- 移除所有文件顶部的
'use strict';
(ESM 默认就是严格模式)。 - 将所有
require()
和module.exports
/exports.*
替换为import
和export
语法。 - 使用完整的相对文件路径:导入本地文件时必须包含文件扩展名(通常是
.js
,即使源文件是.ts
编译后也应导入.js
)。例如:import x from './utils';
需改为import x from './utils.js';
。 - 导入 Node.js 内置模块时,使用
node:
协议前缀,例如:import fs from 'node:fs';
。这能明确区分内置模块和第三方模块,并有助于某些场景下的解析。
- 移除所有文件顶部的
-
TypeScript 类型定义 (如果适用):
- 如果你的包包含类型定义文件 (如
index.d.ts
),确保其中的导入/导出语法也更新为 ESM 格式。
- 如果你的包包含类型定义文件 (如
扩展阅读:如果你对如何为 JavaScript 包添加类型定义感兴趣,可以参考作者的 TypeScript Definition Style Guide。
Q2: 如何在 TypeScript 项目中使用 (或输出) ESM?
是的,完全可以!你需要配置 TypeScript 项目以输出 ESM 格式的代码。
关键步骤:
- TypeScript 版本:确保使用 TypeScript 4.7 或更高版本。
package.json
配置:- 添加
"type": "module"
。 - 将
"main"
替换为"exports"
(同上)。 - 更新
"engines"
至 Node.js 18+ (同上)。
- 添加
tsconfig.json
配置:- 设置
"module": "node16"
或"module": "nodenext"
。非常重要,这指示 TypeScript 生成与 Node.js ESM 兼容的 JavaScript 代码。 - 设置
"moduleResolution": "node16"
或"moduleResolution": "nodenext"
。同样重要,这告诉 TypeScript 编译器如何查找模块,使其行为与 Node.js 的 ESM 解析规则一致。绝对不能设为"node"
! - 示例配置参考:sindresorhus/tsconfig
- 设置
- 代码调整:
- 必须使用
.js
扩展名进行相对导入,即使你实际导入的是.ts
文件。TypeScript 会在编译时正确处理它们。import util from './util';
->import util from './util.js';
- 移除
namespace
用法,改用export
。 - 使用
node:
协议导入 Node.js 内置模块。
- 必须使用
关于 ts-node
:如果使用 ts-node
直接运行 TS 代码,需要遵循 ts-node ESM 指南。参考配置:Example config。(推荐使用 tsx
作为替代品)
Q3: Electron 中如何使用 ESM?
Electron 从版本 28 开始支持 ESM。请参考 Electron ESM 官方文档。
Q4: Webpack 构建遇到 ESM 问题怎么办?
问题很可能出在 Webpack 本身或你的 Webpack 配置上。
- 确保你使用的是最新版本的 Webpack。
- 检查你的
webpack.config.js
是否正确处理了.js
/.mjs
文件以及package.json
中的"type": "module"
。可能需要调整resolve
、module.rules
等配置。 - 不要 在原作者的仓库提问。尝试在 Stack Overflow 提问或在 Webpack 的 GitHub 仓库 提交 issue。
前端补充:Webpack 5 对 ESM 有了更好的原生支持,但与 CJS 混用、依赖项的模块格式、以及 loader/plugin 的兼容性仍可能引发问题。确保 experiments.outputModule
(如果需要输出 ESM bundle) 或 module.rules
中对 .mjs
和 .js
(在 "type": "module"
项目中) 的处理是正确的。Vite 等基于原生 ESM 的构建工具通常能更顺畅地处理 ESM 依赖。
Q5: Next.js 构建遇到 ESM 问题怎么办?
升级到 Next.js 12 或更高版本。Next.js 12 引入了对 ESM 的全面支持,包括 npm 包和 URL Imports。
Q6: Jest 测试遇到 ESM 问题怎么办?
Jest 对 ESM 的支持仍在发展中。
- 阅读 Jest 官方 ESM 文档。
- 你可能需要:
- 在 Node.js 运行时添加
--experimental-vm-modules
标志 (通过NODE_OPTIONS
环境变量)。 - 使用
jest-environment-node
或自定义环境。 - 配置
transform
选项来处理 ESM 语法(如果需要转译)或设置为空对象{}
以禁用 CommonJS 转换。 - 确保你的
package.json
设置了"type": "module"
。
- 在 Node.js 运行时添加
前端补充:Vitest 是一个基于 Vite 的现代测试框架,它原生支持 ESM,并且配置通常比 Jest 更简单,可以作为 Jest 的替代方案。
Q7: TypeScript + ESM 遇到的其他问题?
再次确认:
package.json
中有"type": "module"
。tsconfig.json
中设置了"module": "node16"
(或nodenext
)。- 所有本地文件的导入语句都使用了
.js
扩展名。
Q8: ts-node
+ ESM 遇到的问题?
推荐替代品:考虑使用 tsx
,它提供了更好的 ESM 支持和性能。
如果仍要使用 ts-node
,请确保是最新版本,并遵循 这个指南。示例配置。
Q9: Create React App (CRA) 遇到 ESM 问题怎么办?
CRA 对 Pure ESM 包的支持还不完善。已知问题如 #10933。
- 建议:向 CRA 仓库报告你遇到的具体问题。
- 前端补充:CRA 的底层构建工具 (Webpack) 和配置可能没有完全跟上 ESM 的步伐。对于新项目或需要更好 ESM 支持的项目,可以考虑使用 Vite + React 模板,Vite 对 ESM 的原生支持使其处理这类依赖更加顺畅。
Q10: 如何在 ESM 项目中使用 TypeScript 和 AVA 进行测试?
遵循 AVA 官方 TypeScript 指南 (针对 type: module 包)。
Q11: 如何确保不意外使用 CommonJS 特有的写法?
使用 ESLint 规则来强制执行 ESM 最佳实践:
eslint-plugin-unicorn/prefer-module
: 强制使用 ESM 风格(import
/export
,.js
扩展名等)。eslint-plugin-unicorn/prefer-node-protocol
: 强制使用node:
协议导入内置模块。
Q12: ESM 中没有 __dirname
和 __filename
,怎么办?
使用 import.meta.url
来获取当前模块的 URL。
-
Node.js 20.11+ / 21.2+:
- 可以直接使用
import.meta.dirname
和import.meta.filename
。
- 可以直接使用
-
旧版 Node.js:
import { fileURLToPath } from "node:url";
import path from "node:path";
// 获取当前文件的绝对路径
const __filename = fileURLToPath(import.meta.url);
// 获取当前文件所在目录的绝对路径
const __dirname = path.dirname(__filename); // 或者 path.dirname(fileURLToPath(import.meta.url)) -
更常用的模式 (构造相对于当前模块的路径):
import { fileURLToPath } from "node:url";
// 获取同目录下 foo.js 的文件系统路径
const fooPath = fileURLToPath(new URL("foo.js", import.meta.url)); -
许多 Node.js API 直接接受 URL 对象:
// 直接创建 URL 对象,可能可以直接传递给某些 API (如 fs.readFile)
const fooUrl = new URL("foo.js", import.meta.url);
// await fs.readFile(fooUrl); // 示例
前端补充:在浏览器环境中,import.meta.url
同样可用,它返回的是模块的 URL。但在前端构建打包后,这个值可能指向 blob:
URL 或打包后的文件路径,其行为可能与 Node.js 环境不完全一致。通常在前端代码中直接操作文件系统的场景较少,更多是处理相对资源的 URL。
Q13: 测试时如何导入模块并绕过缓存?
在 ESM 中,没有像 CommonJS delete require.cache[modulePath]
那样标准的、简单的方法来清除缓存。
-
临时方案 (仅限测试,有内存泄漏风险!): 通过给导入路径添加动态查询参数来强制重新加载。
const importFresh = async (modulePath) => {
// 添加时间戳作为查询参数,欺骗模块加载器认为是不同的模块
return import(`${modulePath}?t=${Date.now()}`);
};
// 使用示例
const chalk = (await importFresh("chalk")).default;警告:
- 这种方法会导致内存泄漏,因为旧模块实例不会被垃圾回收。绝对不要在生产环境中使用。
- 它只会重新加载你直接导入的那个模块,不会重新加载其依赖项。
-
未来:Node.js 的 ESM Loader Hooks 成熟后可能会提供更好的解决方案。
前端补充:在浏览器端,模块缓存由浏览器管理。测试框架(如 Vitest)通常有自己的模块模拟(mocking)和隔离机制,不依赖这种 hacky 的缓存清除方式。
Q14: 如何导入 JSON 文件?
-
Node.js 17.5+ (需要
--experimental-json-modules
标志) / Node.js 18.20+ (稳定):- 使用 Import Assertions (导入断言):
import packageJson from './package.json' with { type: 'json' };
console.log(packageJson.version);
- 使用 Import Assertions (导入断言):
-
旧版 Node.js 或不使用实验性特性: * 使用
fs
模块读取文件并手动解析:import fs from 'node:fs/promises';
const packageJsonContent = await fs.readFile('./package.json', 'utf8');
const packageJson = JSON.parse(packageJsonContent);
console.log(packageJson.version);
```
**前端补充**:现代前端构建工具(Webpack, Vite, Rollup 等)通常内置了对 JSON 导入的支持,你可以在代码中直接 `import data from './data.json';`,构建工具会负责处理。Import Assertions 是更标准化的方式,未来可能会被构建工具更广泛地原生支持。
Q15: 何时使用默认导出 (default export) vs 命名导出 (named exports)?
这是一个风格和实践问题,作者给出了他的建议:
-
默认导出 (
export default
):-
适用于一个模块主要导出一个核心功能、类或对象时。
-
例如:一个
left-pad
库主要就是那个填充函数。 -
可以与命名导出结合使用:
// read-json.js
export default function readJson() {
/* ... */
}
export class JSONError extends Error {
/* ... */
}
// usage.js
import readJson, { JSONError } from "read-json";
-
-
命名导出 (
export { ... }
或export const/function/class ...
):-
多个主要 API: 如果一个包提供多个同等重要的功能,特别是包含同步和异步版本时,使用命名导出更清晰。
// read-json.js
export function readJson() {
/* async */
}
export function readJsonSync() {
/* sync */
}
// usage.js
import { readJson, readJsonSync } from "read-json"; // 清晰区分 -
避免模糊命名: 避免使用过于通用的名称作为命名导出,这会迫使消费者重命名以防冲突。
// ❌ 不好的例子: parse-json.js
// export function parse() { ... }
// 消费者需要重命名
// import { parse as parseJson } from 'parse-json';
// ✅ 好的例子: parse-json.js
export function parseJson() { ... }
// 消费者直接使用
import { parseJson } from 'parse-json'; -
取代命名空间模式: ESM 中,倾向于使用描述性的命名导出,而不是像 CommonJS 那样导出一个包含多个方法的对象(命名空间)。
// CommonJS (旧)
// const isStream = require('is-stream');
// isStream.writable(stream);
// ESM (推荐)
// is-stream.js
// export function isStream() { ... }
// export function isReadableStream() { ... }
// export function isWritableStream() { ... }
// usage.js
import { isWritableStream } from "is-stream";
isWritableStream(stream);
-
前端补充:
- Tree Shaking: 命名导出通常对 Tree Shaking 更友好。构建工具能更容易地静态分析出哪些命名导出被使用了,从而移除未使用的代码。默认导出如果是对象或类,其内部方法的摇树优化可能需要更复杂的分析或特定写法。
- 组件库: 大型组件库通常使用命名导出来导出各个组件,方便用户按需导入:
import { Button, Modal } from 'my-ui-library';
。 - 可读性与可发现性: 命名导出使得模块提供的功能更加一目了然,IDE 也能提供更好的自动补全提示。
总结
转向 Pure ESM 是 JavaScript 生态系统发展的必然趋势。虽然短期内会给使用 CommonJS 的项目带来一些挑战,但理解 ESM 的基本原理、掌握迁移步骤和解决常见问题的策略至关重要。积极拥抱 ESM 不仅能让你使用最新的库和工具,还能享受到模块标准化、性能优化(如更好的 Tree Shaking)和与浏览器环境更一致的开发体验。对于前端开发者而言,熟悉 ESM 更是现代 Web 开发的基础。